package edu.washington.cs.sounddetector.server.api;

import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.URI;
import java.util.Date;
import java.util.List;
import java.util.Map;

import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.StatusLine;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.util.EntityUtils;

import edu.washington.cs.sounddetector.server.api.exception.CantBuildClassifierException;
import edu.washington.cs.sounddetector.server.api.exception.DetectorDoesNotExistException;
import edu.washington.cs.sounddetector.server.api.exception.PhoneDoesNotExistException;
import edu.washington.cs.sounddetector.server.api.exception.SoundClassDoesNotExistException;
import edu.washington.cs.sounddetector.server.api.exception.SoundDetectorInternalErrorException;
import edu.washington.cs.sounddetector.server.api.exception.SoundDoesNotExistException;
import edu.washington.cs.sounddetector.server.api.method.Classify;
import edu.washington.cs.sounddetector.server.api.method.GetOldNotifications;
import edu.washington.cs.sounddetector.server.api.method.GetPendingNotifications;
import edu.washington.cs.sounddetector.server.api.method.GetSound;
import edu.washington.cs.sounddetector.server.api.method.GetSounds;
import edu.washington.cs.sounddetector.server.api.method.RegisterReceiverPhone;
import edu.washington.cs.sounddetector.server.api.method.RenameDetector;
import edu.washington.cs.sounddetector.server.api.method.SetSoundClassProperties;
import edu.washington.cs.sounddetector.server.api.method.Train;
import edu.washington.cs.sounddetector.server.api.method.UnregisterReceiverPhone;
import edu.washington.cs.sounddetector.server.api.method.Untrain;
import edu.washington.cs.sounddetector.server.api.methodresult.ClassifyResult;
import edu.washington.cs.sounddetector.server.api.methodresult.GetOldNotificationsResult;
import edu.washington.cs.sounddetector.server.api.methodresult.GetPendingNotificationsResult;
import edu.washington.cs.sounddetector.server.api.methodresult.GetSoundResult;
import edu.washington.cs.sounddetector.server.api.methodresult.GetSoundsResult;
import edu.washington.cs.sounddetector.server.api.methodresult.RegisterReceiverPhoneResult;
import edu.washington.cs.sounddetector.server.api.methodresult.RenameDetectorResult;
import edu.washington.cs.sounddetector.server.api.methodresult.SetSoundClassPropertiesResult;
import edu.washington.cs.sounddetector.server.api.methodresult.TrainResult;
import edu.washington.cs.sounddetector.server.api.methodresult.UnregisterReceiverPhoneResult;
import edu.washington.cs.sounddetector.server.api.methodresult.UntrainResult;
import edu.washington.cs.sounddetector.server.api.object.LabeledSound;
import edu.washington.cs.sounddetector.server.api.object.Notification;
import edu.washington.cs.sounddetector.server.api.object.PeakEvent;
import edu.washington.cs.sounddetector.server.api.object.SoundClass;
import edu.washington.cs.sounddetector.server.api.object.SoundFormat;

/**
 * <p>
 * Class for clients of the API.
 * </p>
 * 
 * <p>
 * SoundDetectorAPI is mutable and not threadsafe.
 * </p>
 * 
 * @author the.dylan.price@gmail.com
 * 
 */
public class SoundDetectorAPI
{

    private HttpClient httpClient;
    private URI serverURI;

    /**
     * Constructs a new SoundDetectorAPI.
     * 
     * @param serverURI
     *            the uri of the running sound-detector server
     * @param httpClient
     *            an http client that the SoundDetectorAPI will use to make requests
     */
    public SoundDetectorAPI(URI serverURI, HttpClient httpClient)
    {
        this.serverURI = serverURI;
        this.httpClient = httpClient;
    }

    /**
     * Registers a new Receiver Phone with the server. If the given phoneId is already registered,
     * then overwrites the entry with the given registrationId.
     * 
     * @param phoneId
     *            a unique identifier for the phone
     * @param registrationId
     *            registration Id obtained from the <a
     *            href="http://code.google.com/android/c2dm/">Android Cloud to Device Messaging
     *            framework</a>. May be null if you do not wish to receive push notifications. In
     *            this case you can manually poll the server using {@link #getPendingNotifications}
     *            .
     * @throws SoundDetectorInternalErrorException
     *             if the server encounters an error that it does not know how to deal with
     * @throws IOException
     *             if there is a problem communicating with the server
     * @throws ClientProtocolException
     *             if there is an error in the HTTP protocol
     */
    public void registerReceiverPhone(String phoneId, String registrationId)
            throws ClientProtocolException, IOException, SoundDetectorInternalErrorException
    {
        RegisterReceiverPhone method = new RegisterReceiverPhone(phoneId, registrationId);
        sendMethod(method, RegisterReceiverPhoneResult.class);
    }

    /**
     * Unregisters the given phone with the server.
     * 
     * @param phoneId
     *            the phoneId to unregister
     * @return true if the phone was unregistered, false if the phone did not exist on the server in
     *         the first place
     * @throws SoundDetectorInternalErrorException
     *             if the server encounters an error that it does not know how to deal with
     * @throws IOException
     *             if there is a problem communicating with the server
     * @throws ClientProtocolException
     *             if there is an error in the HTTP protocol
     */
    public boolean unregisterReceiverPhone(String phoneId) throws ClientProtocolException,
            IOException, SoundDetectorInternalErrorException
    {
        UnregisterReceiverPhone method = new UnregisterReceiverPhone(phoneId);
        UnregisterReceiverPhoneResult result = sendMethod(method,
                UnregisterReceiverPhoneResult.class);
        return result.isSuccessful();
    }

    /**
     * Sends training data to the server.
     * 
     * @param detectorId
     *            the location of the phone that detected the sound
     * @param data
     *            a list of sound samples labeled with sound class names
     * @param soundFormat
     *            the format of the sound samples
     * @return a list of ids corresponding to the LabeledSounds sent to the server. Use these ids to
     *         identify the sounds to the server in other API calls.
     * @throws CantBuildClassifierException
     *             if the classifier can't be built. Known reasons this may happen:
     *             <ul>
     *             <li>all of the data you have ever sent up has been labeled 'unknown'--the
     *             classifier must have at least two sound classes to work with</li>
     *             </ul>
     * @throws SoundDetectorInternalErrorException
     *             if the server encounters an error that it does not know how to deal with
     * @throws IOException
     *             if there is a problem communicating with the server
     * @throws ClientProtocolException
     *             if there is an error in the HTTP protocol
     */
    public List<Long> train(String detectorId, List<LabeledSound> data, SoundFormat soundFormat)
            throws ClientProtocolException, IOException, SoundDetectorInternalErrorException,
            CantBuildClassifierException
    {
        Train method = new Train(detectorId, data, soundFormat);
        TrainResult result = sendMethod(method, TrainResult.class);
        if (!result.isSuccessful())
        {
            switch (result.getReason())
            {
            case CANT_BUILD_CLASSIFIER:
                throw new CantBuildClassifierException(result.getMessage());
            }
        }
        return result.getSoundIds();
    }

    /**
     * Untrains sounds from the server's classifier.
     * 
     * @param soundIds
     *            the ids returned by a call to {@link #train}, identifying sounds which are to be
     *            deleted from the server and removed from the server's classifier.
     * @throws CantBuildClassifierException
     *             if after deleting the sounds the server could not build the classifier. In this
     *             case the server is in the same state it was before the call to untrain.
     * @throws SoundDetectorInternalErrorException
     *             if the server encounters an error that it does not know how to deal with
     * @throws IOException
     *             if there is a problem communicating with the server
     * @throws ClientProtocolException
     *             if there is an error in the HTTP protocol
     */
    public void untrain(List<Long> soundIds) throws ClientProtocolException, IOException,
            SoundDetectorInternalErrorException, CantBuildClassifierException
    {
        Untrain method = new Untrain(soundIds);
        UntrainResult result = sendMethod(method, UntrainResult.class);
        if (!result.isSuccessful())
        {
            switch (result.getReason())
            {
            case CANT_BUILD_CLASSIFIER:
                throw new CantBuildClassifierException(result.getMessage());
            }
        }
    }

    /**
     * Notify the server that a sound event was detected.
     * 
     * @param detectorId
     *            the location of the phone that detected the sound
     * @param sound
     *            the audio event that was detected
     * @param soundFormat
     *            the format of the sound sample
     * @param peakEvents
     *            a list of peak events for the audio event
     * @return the most likely class of the sound
     * @throws SoundDetectorInternalErrorException
     *             if the server encounters an error that it does not know how to deal with
     * @throws IOException
     *             if there is a problem communicating with the server
     * @throws ClientProtocolException
     *             if there is an error in the HTTP protocol
     */
    public String classify(String detectorId, byte[] sound, SoundFormat soundFormat,
            List<PeakEvent> peakEvents) throws ClientProtocolException, IOException,
            SoundDetectorInternalErrorException
    {
        Classify method = new Classify(detectorId, sound, soundFormat, peakEvents);
        ClassifyResult result = sendMethod(method, ClassifyResult.class);
        return result.getSoundClass();
    }

    /**
     * Retrieves the latest notifications the given phone has not yet seen from the server.
     * 
     * @param phoneId
     *            the id of the phone which has been registered with {@link #registerReceiverPhone}.
     * @return a list of notifications this phone has not yet seen
     * @throws PhoneDoesNotExistException
     *             if no phone with phoneId has been registered with a call to
     *             {@link #registerReceiverPhone}.
     * @throws SoundDetectorInternalErrorException
     *             if the server encounters an error that it does not know how to deal with
     * @throws IOException
     *             if there is a problem communicating with the server
     * @throws ClientProtocolException
     *             if there is an error in the HTTP protocol
     */
    public List<Notification> getPendingNotifications(String phoneId)
            throws ClientProtocolException, IOException, SoundDetectorInternalErrorException,
            PhoneDoesNotExistException
    {
        GetPendingNotifications method = new GetPendingNotifications(phoneId);
        GetPendingNotificationsResult result = sendMethod(method,
                GetPendingNotificationsResult.class);
        if (!result.isSuccessful())
        {
            switch (result.getReason())
            {
            case PHONE_DOES_NOT_EXIST:
                throw new PhoneDoesNotExistException(result.getPhoneId());
            }
        }
        return result.getNotifications();
    }

    /**
     * Retrieves a list of notifications that have already been seen by the given phone.
     * 
     * @param phoneId
     *            the id of the phone which has been registered with {@link #registerReceiverPhone}.
     * @param greaterThanDate
     *            retrieve notifications which were created after this date
     * @param lessThanDate
     *            retrieve notifications which were created before this date
     * @return a list of notifications
     * @throws PhoneDoesNotExistException
     *             if no phone with phoneId has been registered with a call to
     *             {@link #registerReceiverPhone}.
     * @throws SoundDetectorInternalErrorException
     *             if the server encounters an error that it does not know how to deal with
     * @throws IOException
     *             if there is a problem communicating with the server
     * @throws ClientProtocolException
     *             if there is an error in the HTTP protocol
     */
    public List<Notification> getOldNotifications(String phoneId, Date greaterThanDate,
            Date lessThanDate) throws PhoneDoesNotExistException, ClientProtocolException,
            IOException, SoundDetectorInternalErrorException
    {
        GetOldNotifications method = new GetOldNotifications(phoneId, greaterThanDate, lessThanDate);
        GetOldNotificationsResult result = sendMethod(method, GetOldNotificationsResult.class);
        if (!result.isSuccessful())
        {
            switch (result.getReason())
            {
            case PHONE_DOES_NOT_EXIST:
                throw new PhoneDoesNotExistException(result.getPhoneId());
            }
        }
        return result.getNotifications();
    }

    /**
     * Retrieves all sounds saved to the server (from previous calls to {@link #train}) for the
     * given detector.
     * 
     * @param detectorId
     *            the id of the detector
     * @return a map from sound classes to a list of sound ids. You can retrieve the actual sound
     *         using {@link #getSound}.
     * @throws DetectorDoesNotExistException
     *             if there are no sounds with the given detectorId
     * @throws SoundDetectorInternalErrorException
     *             if the server encounters an error that it does not know how to deal with
     * @throws IOException
     *             if there is a problem communicating with the server
     * @throws ClientProtocolException
     *             if there is an error in the HTTP protocol
     */
    public Map<SoundClass, List<Long>> getSounds(String detectorId) throws ClientProtocolException,
            IOException, SoundDetectorInternalErrorException, DetectorDoesNotExistException
    {
        GetSounds method = new GetSounds(detectorId);
        GetSoundsResult result = sendMethod(method, GetSoundsResult.class);
        if (!result.isSuccessful())
        {
            switch (result.getReason())
            {
            case DETECTOR_ID_DOES_NOT_EXIST:
                throw new DetectorDoesNotExistException(result.getDetectorId());

            }
        }
        return result.getSounds();
    }

    //TODO: Kyle note - want to be able to get all detectorIds mapped to a list of soundIds

    /**
     * Retrieves the given sound from the server.
     * 
     * @param soundId
     *            the identifier of the sound, from a previous call to {@link #train} or
     *            {@link #getSounds}.
     * @return the sound as a byte array
     * @throws SoundDoesNotExistException
     *             if there is no such sound with the given soundId
     * @throws SoundDetectorInternalErrorException
     *             if the server encounters an error that it does not know how to deal with
     * @throws IOException
     *             if there is a problem communicating with the server
     * @throws ClientProtocolException
     *             if there is an error in the HTTP protocol
     */
    public byte[] getSound(long soundId) throws ClientProtocolException, IOException,
            SoundDetectorInternalErrorException, SoundDoesNotExistException
    {
        GetSound method = new GetSound(soundId);
        GetSoundResult result = sendMethod(method, GetSoundResult.class);
        if (!result.isSuccessful())
        {
            switch (result.getReason())
            {
            case SOUND_ID_DOES_NOT_EXIST:
                throw new SoundDoesNotExistException(result.getSoundId());
            }
        }
        return result.getSound();
    }

    /**
     * Set properties on sound classes.
     * 
     * @param detectorId
     *            the detector
     * @param soundClasses
     *            a list of sound classes and properties for the detector
     * @throws DetectorDoesNotExistException
     *             if no detector with detectorId exists
     * @throws SoundClassDoesNotExistException
     *             if one of the sound classes does not exist
     * @throws SoundDetectorInternalErrorException
     *             if the server encounters an error that it does not know how to deal with
     * @throws IOException
     *             if there is a problem communicating with the server
     * @throws ClientProtocolException
     *             if there is an error in the HTTP protocol
     */
    public void setSoundClassProperties(String detectorId, List<SoundClass> soundClasses)
            throws DetectorDoesNotExistException, ClientProtocolException, IOException,
            SoundDetectorInternalErrorException, SoundClassDoesNotExistException
    {
        SetSoundClassProperties method = new SetSoundClassProperties(detectorId, soundClasses);
        SetSoundClassPropertiesResult result = sendMethod(method,
                SetSoundClassPropertiesResult.class);
        if (!result.isSuccessful())
        {
            switch (result.getReason())
            {
            case DETECTOR_ID_DOES_NOT_EXIST:
                throw new DetectorDoesNotExistException(result.getDetectorId());
            case SOUND_CLASS_DOES_NOT_EXIST:
                throw new SoundClassDoesNotExistException(result.getSoundClass());
            }
        }
    }

    /**
     * Rename a detector.
     * 
     * @param oldDetectorId
     *            the old detector id
     * @param newDetectorId
     *            the new detector id
     * @throws DetectorDoesNotExistException
     *             if no detector with detectorId exists
     * @throws SoundDetectorInternalErrorException
     *             if the server encounters an error that it does not know how to deal with
     * @throws IOException
     *             if there is a problem communicating with the server
     * @throws ClientProtocolException
     *             if there is an error in the HTTP protocol
     */
    public void renameDetector(String oldDetectorId, String newDetectorId)
            throws ClientProtocolException, IOException, SoundDetectorInternalErrorException,
            DetectorDoesNotExistException
    {
        RenameDetector method = new RenameDetector(oldDetectorId, newDetectorId);
        RenameDetectorResult result = sendMethod(method, RenameDetectorResult.class);
        if (!result.isSuccessful())
        {
            switch (result.getReason())
            {
            case DETECTOR_ID_DOES_NOT_EXIST:
                throw new DetectorDoesNotExistException(result.getDetectorId());
            }
        }
    }

    /**
     * @return the server URI
     */
    public URI getServerURI()
    {
        return this.serverURI;
    }

    /**
     * @param serverURI
     *            the server URI to set
     */
    public void setURI(URI serverURI)
    {
        this.serverURI = serverURI;
    }

    /**
     * @return the http client
     */
    public HttpClient getHttpClient()
    {
        return this.httpClient;
    }

    /**
     * @param httpClient
     *            the HttpClient to use for API calls
     */
    public void setHttpClient(HttpClient httpClient)
    {
        this.httpClient = httpClient;
    }

    /**
     * Sends the given method to the server.
     * 
     * @param method
     *            the API method to call
     * @return the result from the command.
     * @throws ClientProtocolException
     * @throws IOException
     * @throws SoundDetectorInternalErrorException
     */
    protected <T extends MethodResult<?>> T sendMethod(Method method, Class<T> methodResultClass)
            throws ClientProtocolException, IOException, SoundDetectorInternalErrorException
    {
        String methodPath = StringUtils.uncapitalize(method.getClass().getSimpleName());
        URI uri = URI.create(serverURI.toString() + APIConsts.API_PATH + methodPath).normalize();
        String json = MethodConverter.getInstance().serializeMethod(method);
        HttpPost post = new HttpPost(uri);
        HttpEntity entity = new StringEntity(json);
        post.setEntity(entity);
        HttpResponse response = httpClient.execute(post);
        StatusLine status = response.getStatusLine();
        int responseHundreds = status.getStatusCode() / 100;
        if (responseHundreds == 4 || responseHundreds == 5)
        {
            String message = EntityUtils.toString(response.getEntity());
            throw new SoundDetectorInternalErrorException(message);
        }
        Reader reader = new InputStreamReader(response.getEntity().getContent());
        T result = MethodConverter.getInstance().deserializeResult(reader, methodResultClass);
        reader.close();
        return result;
    }

}
